Refactor common code from google.oauth2.flow to google.oauth2.oauthlib (#106) 
diff --git a/docs/reference/google.oauth2.oauthlib.rst b/docs/reference/google.oauth2.oauthlib.rst new file mode 100644 index 0000000..c687490 --- /dev/null +++ b/docs/reference/google.oauth2.oauthlib.rst 
@@ -0,0 +1,7 @@ +google.oauth2.oauthlib module +============================= + +.. automodule:: google.oauth2.oauthlib + :members: + :inherited-members: + :show-inheritance: 
diff --git a/docs/reference/google.oauth2.rst b/docs/reference/google.oauth2.rst index 5dc2406..4dd1ebd 100644 --- a/docs/reference/google.oauth2.rst +++ b/docs/reference/google.oauth2.rst 
@@ -14,5 +14,6 @@  google.oauth2.credentials  google.oauth2.flow  google.oauth2.id_token + google.oauth2.oauthlib  google.oauth2.service_account   
diff --git a/google/oauth2/flow.py b/google/oauth2/flow.py index a4dcfca..e70ee3d 100644 --- a/google/oauth2/flow.py +++ b/google/oauth2/flow.py 
@@ -55,12 +55,9 @@    import json   -import requests_oauthlib -  import google.auth.transport.requests  import google.oauth2.credentials - -_REQUIRED_CONFIG_KEYS = frozenset(('auth_uri', 'token_uri', 'client_id')) +import google.oauth2.oauthlib      class Flow(object): @@ -82,9 +79,33 @@  https://console.developers.google.com/apis/credentials  """   - def __init__(self, client_config, scopes, **kwargs): + def __init__(self, oauth2session, client_type, client_config):  """  Args: + oauth2session (requests_oauthlib.OAuth2Session): + The OAuth 2.0 session from ``requests-oauthlib``. + client_type (str): The client type, either ``web`` or + ``installed``. + client_config (Mapping[str, Any]): The client + configuration in the Google `client secrets`_ format. + + .. _client secrets: + https://developers.google.com/api-client-library/python/guide + /aaa_client_secrets + """ + self.client_type = client_type + """str: The client type, either ``'web'`` or ``'installed'``""" + self.client_config = client_config[client_type] + """Mapping[str, Any]: The OAuth 2.0 client configuration.""" + self.oauth2session = oauth2session + """requests_oauthlib.OAuth2Session: The OAuth 2.0 session.""" + + @classmethod + def from_client_config(cls, client_config, scopes, **kwargs): + """Creates a :class:`requests_oauthlib.OAuth2Session` from client + configuration loaded from a Google-format client secrets file. + + Args:  client_config (Mapping[str, Any]): The client  configuration in the Google `client secrets`_ format.  scopes (Sequence[str]): The list of scopes to request during the @@ -92,6 +113,9 @@  kwargs: Any additional parameters passed to  :class:`requests_oauthlib.OAuth2Session`   + Returns: + Flow: The constructed Flow instance. +  Raises:  ValueError: If the client configuration is not in the correct  format. @@ -100,29 +124,19 @@  https://developers.google.com/api-client-library/python/guide  /aaa_client_secrets  """ - self.client_config = None - """Mapping[str, Any]: The OAuth 2.0 client configuration.""" - self.client_type = None - """str: The client type, either ``'web'`` or ``'installed'``""" -  if 'web' in client_config: - self.client_config = client_config['web'] - self.client_type = 'web' + client_type = 'web'  elif 'installed' in client_config: - self.client_config = client_config['installed'] - self.client_type = 'installed' + client_type = 'installed'  else:  raise ValueError(  'Client secrets must be for a web or installed app.')   - if not _REQUIRED_CONFIG_KEYS.issubset(self.client_config.keys()): - raise ValueError('Client secrets is not in the correct format.') + session, client_config = ( + google.oauth2.oauthlib.session_from_client_config( + client_config, scopes, **kwargs))   - self.oauth2session = requests_oauthlib.OAuth2Session( - client_id=self.client_config['client_id'], - scope=scopes, - **kwargs) - """requests_oauthlib.OAuth2Session: The OAuth 2.0 session.""" + return cls(session, client_type, client_config)    @classmethod  def from_client_secrets_file(cls, client_secrets_file, scopes, **kwargs): @@ -142,7 +156,7 @@  with open(client_secrets_file, 'r') as json_file:  client_config = json.load(json_file)   - return cls(client_config, scopes=scopes, **kwargs) + return cls.from_client_config(client_config, scopes=scopes, **kwargs)    @property  def redirect_uri(self): @@ -226,18 +240,8 @@  Raises:  ValueError: If there is no access token in the session.  """ - if not self.oauth2session.token: - raise ValueError( - 'There is no access token for this session, did you call ' - 'fetch_token?') - - return google.oauth2.credentials.Credentials( - self.oauth2session.token['access_token'], - refresh_token=self.oauth2session.token['refresh_token'], - token_uri=self.client_config['token_uri'], - client_id=self.client_config['client_id'], - client_secret=self.client_config['client_secret'], - scopes=self.oauth2session.scope) + return google.oauth2.oauthlib.credentials_from_session( + self.oauth2session, self.client_config)    def authorized_session(self):  """Returns a :class:`requests.Session` authorized with credentials. 
diff --git a/google/oauth2/oauthlib.py b/google/oauth2/oauthlib.py new file mode 100644 index 0000000..8f5c105 --- /dev/null +++ b/google/oauth2/oauthlib.py 
@@ -0,0 +1,142 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration with oauthlib + +.. warning:: + This module is experimental and is subject to change signficantly + within major version releases. + +This module provides helpers for integrating with `requests-oauthlib`_. +Typically, you'll want to use the higher-level helpers in +:mod:`google.oauth2.flow`. + +.. _requests-oauthlib: http://requests-oauthlib.readthedocs.io/en/stable/ +""" + +import json + +import requests_oauthlib + +import google.oauth2.credentials + +_REQUIRED_CONFIG_KEYS = frozenset(('auth_uri', 'token_uri', 'client_id')) + + +def session_from_client_config(client_config, scopes, **kwargs): + """Creates a :class:`requests_oauthlib.OAuth2Session` from client + configuration loaded from a Google-format client secrets file. + + Args: + client_config (Mapping[str, Any]): The client + configuration in the Google `client secrets`_ format. + scopes (Sequence[str]): The list of scopes to request during the + flow. + kwargs: Any additional parameters passed to + :class:`requests_oauthlib.OAuth2Session` + + Raises: + ValueError: If the client configuration is not in the correct + format. + + Returns: + Tuple[requests_oauthlib.OAuth2Session, Mapping[str, Any]]: The new + oauthlib session and the validated client configuration. + + .. _client secrets: + https://developers.google.com/api-client-library/python/guide + /aaa_client_secrets + """ + + if 'web' in client_config: + config = client_config['web'] + elif 'installed' in client_config: + config = client_config['installed'] + else: + raise ValueError( + 'Client secrets must be for a web or installed app.') + + if not _REQUIRED_CONFIG_KEYS.issubset(config.keys()): + raise ValueError('Client secrets is not in the correct format.') + + session = requests_oauthlib.OAuth2Session( + client_id=config['client_id'], + scope=scopes, + **kwargs) + + return session, client_config + + +def session_from_client_secrets_file(client_secrets_file, scopes, **kwargs): + """Creates a :class:`requests_oauthlib.OAuth2Session` instance from a + Google-format client secrets file. + + Args: + client_secrets_file (str): The path to the `client secrets`_ .json + file. + scopes (Sequence[str]): The list of scopes to request during the + flow. + kwargs: Any additional parameters passed to + :class:`requests_oauthlib.OAuth2Session` + + Returns: + Tuple[requests_oauthlib.OAuth2Session, Mapping[str, Any]]: The new + oauthlib session and the validated client configuration. + + .. _client secrets: + https://developers.google.com/api-client-library/python/guide + /aaa_client_secrets + """ + with open(client_secrets_file, 'r') as json_file: + client_config = json.load(json_file) + + return session_from_client_config(client_config, scopes, **kwargs) + + +def credentials_from_session(session, client_config=None): + """Creates :class:`google.oauth2.credentials.Credentials` from a + :class:`requests_oauthlib.OAuth2Session`. + + :meth:`fetch_token` must be called on the session before before calling + this. This uses the session's auth token and the provided client + configuration to create :class:`google.oauth2.credentials.Credentials`. + This allows you to use the credentials from the session with Google + API client libraries. + + Args: + session (requests_oauthlib.OAuth2Session): The OAuth 2.0 session. + client_config (Mapping[str, Any]): The subset of the client + configuration to use. For example, if you have a web client + you would pass in `client_config['web']`. + + Returns: + google.oauth2.credentials.Credentials: The constructed credentials. + + Raises: + ValueError: If there is no access token in the session. + """ + client_config = client_config if client_config is not None else {} + + if not session.token: + raise ValueError( + 'There is no access token for this session, did you call ' + 'fetch_token?') + + return google.oauth2.credentials.Credentials( + session.token['access_token'], + refresh_token=session.token.get('refresh_token'), + token_uri=client_config.get('token_uri'), + client_id=client_config.get('client_id'), + client_secret=client_config.get('client_secret'), + scopes=session.scope) 
diff --git a/tests/oauth2/test_flow.py b/tests/oauth2/test_flow.py index 7fc268c..e5d108f 100644 --- a/tests/oauth2/test_flow.py +++ b/tests/oauth2/test_flow.py 
@@ -27,32 +27,6 @@  CLIENT_SECRETS_INFO = json.load(fh)     -def test_constructor_web(): - instance = flow.Flow(CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes) - assert instance.client_config == CLIENT_SECRETS_INFO['web'] - assert (instance.oauth2session.client_id == - CLIENT_SECRETS_INFO['web']['client_id']) - assert instance.oauth2session.scope == mock.sentinel.scopes - - -def test_constructor_installed(): - info = {'installed': CLIENT_SECRETS_INFO['web']} - instance = flow.Flow(info, scopes=mock.sentinel.scopes) - assert instance.client_config == info['installed'] - assert instance.oauth2session.client_id == info['installed']['client_id'] - assert instance.oauth2session.scope == mock.sentinel.scopes - - -def test_constructor_bad_format(): - with pytest.raises(ValueError): - flow.Flow({}, scopes=[]) - - -def test_constructor_missing_keys(): - with pytest.raises(ValueError): - flow.Flow({'web': {}}, scopes=[]) - -  def test_from_client_secrets_file():  instance = flow.Flow.from_client_secrets_file(  CLIENT_SECRETS_FILE, scopes=mock.sentinel.scopes) @@ -62,9 +36,25 @@  assert instance.oauth2session.scope == mock.sentinel.scopes     +def test_from_client_config_installed(): + client_config = {'installed': CLIENT_SECRETS_INFO['web']} + instance = flow.Flow.from_client_config( + client_config, scopes=mock.sentinel.scopes) + assert instance.client_config == client_config['installed'] + assert (instance.oauth2session.client_id == + client_config['installed']['client_id']) + assert instance.oauth2session.scope == mock.sentinel.scopes + + +def test_from_client_config_bad_format(): + with pytest.raises(ValueError): + flow.Flow.from_client_config({}, scopes=mock.sentinel.scopes) + +  @pytest.fixture  def instance(): - yield flow.Flow(CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes) + yield flow.Flow.from_client_config( + CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes)      def test_redirect_uri(instance): @@ -123,11 +113,6 @@  assert credentials._token_uri == CLIENT_SECRETS_INFO['web']['token_uri']     -def test_bad_credentials(instance): - with pytest.raises(ValueError): - assert instance.credentials - -  def test_authorized_session(instance):  instance.oauth2session.token = {  'access_token': mock.sentinel.access_token, 
diff --git a/tests/oauth2/test_oauthlib.py b/tests/oauth2/test_oauthlib.py new file mode 100644 index 0000000..a16c904 --- /dev/null +++ b/tests/oauth2/test_oauthlib.py 
@@ -0,0 +1,92 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import os + +import mock +import pytest + +from google.oauth2 import oauthlib + +DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data') +CLIENT_SECRETS_FILE = os.path.join(DATA_DIR, 'client_secrets.json') + +with open(CLIENT_SECRETS_FILE, 'r') as fh: + CLIENT_SECRETS_INFO = json.load(fh) + + +def test_session_from_client_config_web(): + session, config = oauthlib.session_from_client_config( + CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes) + + assert config == CLIENT_SECRETS_INFO + assert session.client_id == CLIENT_SECRETS_INFO['web']['client_id'] + assert session.scope == mock.sentinel.scopes + + +def test_session_from_client_config_installed(): + info = {'installed': CLIENT_SECRETS_INFO['web']} + session, config = oauthlib.session_from_client_config( + info, scopes=mock.sentinel.scopes) + assert config == info + assert session.client_id == info['installed']['client_id'] + assert session.scope == mock.sentinel.scopes + + +def test_session_from_client_config_bad_format(): + with pytest.raises(ValueError): + oauthlib.session_from_client_config({}, scopes=[]) + + +def test_session_from_client_config_missing_keys(): + with pytest.raises(ValueError): + oauthlib.session_from_client_config({'web': {}}, scopes=[]) + + +def test_session_from_client_secrets_file(): + session, config = oauthlib.session_from_client_secrets_file( + CLIENT_SECRETS_FILE, scopes=mock.sentinel.scopes) + assert config == CLIENT_SECRETS_INFO + assert session.client_id == CLIENT_SECRETS_INFO['web']['client_id'] + assert session.scope == mock.sentinel.scopes + + +@pytest.fixture +def session(): + session, _ = oauthlib.session_from_client_config( + CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes) + yield session + + +def test_credentials_from_session(session): + session.token = { + 'access_token': mock.sentinel.access_token, + 'refresh_token': mock.sentinel.refresh_token + } + + credentials = oauthlib.credentials_from_session( + session, CLIENT_SECRETS_INFO['web']) + + assert credentials.token == mock.sentinel.access_token + assert credentials._refresh_token == mock.sentinel.refresh_token + assert credentials._client_id == CLIENT_SECRETS_INFO['web']['client_id'] + assert (credentials._client_secret == + CLIENT_SECRETS_INFO['web']['client_secret']) + assert credentials._token_uri == CLIENT_SECRETS_INFO['web']['token_uri'] + + +def test_bad_credentials(session): + with pytest.raises(ValueError): + oauthlib.credentials_from_session(session)